查看原文
其他

【实战】.NET Core API 框架实现接口的JWT授权验证

DotNet 2019-08-03

(点击上方蓝字,可快速关注我们)


来源:在7楼

cnblogs.com/RayWang/p/9255093.html


源码已上传Github:https://github.com/WangRui321/RayPI_V2.0


一、根


根据维基百科定义,JWT(读作 [/dʒɒt/]),即JSON Web Tokens,是一种基于JSON的、用于在网络上声明某种主张的令牌(token)。


JWT通常由三部分组成: 头信息(header), 消息体(payload)和签名(signature)。它是一种用于双方之间传递安全信息的表述性声明规范。


JWT作为一个开放的标准(RFC 7519),定义了一种简洁的、自包含的方法,从而使通信双方实现以JSON对象的形式安全的传递信息。


以上是JWT的官方解释,可以看出JWT并不是一种只能权限验证的工具,而是一种标准化的数据传输规范。所以,只要是在系统之间需要传输简短但却需要一定安全等级的数据时,都可以使用JWT规范来传输。规范是不因平台而受限制的,这也是JWT做为授权验证可以跨平台的原因。


如果理解还是有困难的话,我们可以拿JWT和JSON类比:


JSON是一种轻量级的数据交换格式,是一种数据层次结构规范。它并不是只用来给接口传递数据的工具,只要有层级结构的数据都可以使用JSON来存储和表示。当然,JSON也是跨平台的,不管是Win还是Linux,.NET还是Java,都可以使用它作为数据传输形式。


该篇的主要目的是实战,所以关于JWT本身的优点,以及使用JWT作为系统授权验证的优缺点,这里就不细说了,感兴趣的可以自己去查阅相关资料。


1.1 在授权验证系统中,JWT是怎么工作的呢?


如果将JWT运用到Web Api的授权验证中,那么它的工作原理是这样的:



 


1)客户端向授权服务系统发起请求,申请获取“令牌”。


2)授权服务根据用户身份,生成一张专属“令牌”,并将该“令牌”以JWT规范返回给客户端


3)客户端将获取到的“令牌”放到http请求的headers中后,向主服务系统发起请求。主服务系统收到请求后会从headers中获取“令牌”,并从“令牌”中解析出该用户的身份权限,然后做出相应的处理(同意或拒绝返回资源)


可以看出,JWT授权服务是可以脱离我们的主服务系统而作为一个独立系统存在的。


1.2 令牌是什么?JWT就是令牌吗?


前面说了其实把JWT理解为一种规范更为贴切,但是往往大家把根据JWT规则生成的加密字符串也叫作JWT,还有人直接称呼JWT为令牌。本文为了阐述方便,特此做了一些区分:


1.2.1 JWT:


本文所说的JWT皆指的是JWT规范


1.2.2 JWT字符串:


本文所说的“JWT字符串”是指通过JWT规则加密后生成的字符串,它由三本分组成:Header(头部)、Payload(数据)、Signature(签名),将这三部分由‘.’连接而组成的一长串加密字符串就成为JWT字符串。


1)Header


由且只由两个数据组成,一个是“alg”(加密规范)指定了该JWT字符串的加密规则,另一个是“typ”(JWT字符串类型)。例如:


{

  "alg": "HS256",

  "typ": "JWT"

}


将这组JSON格式的数据通过Base64Url格式编码后,生成的字符串就是我们JWT字符串的第一个部分。


2)Payload


由一组数据组成,它负责传递数据,我们可以添加一些已注册声明,比如“iss”(JWT字符串的颁发人名称)、“exp”(该JWT字符串的过期时间)、“sub”(身份)、“aud”(受众),除了这些,我们还可根据需要添加自定义的需要传输的数据,一般是发起请求的用户的信息。例如:


{

  “iss”:"RayPI",

  "sub": "Client",

  "name": "张三",

  "uid": 1

}


将该JSON格式的数据通过Base64Url格式编码后,生成的字符串就是我们JWT字符串的第二部分。


3)Signature


数字签名,由4个因素所同时决定:编码后的header字符串,编码后的payload字符串,之前在头部声明的加密算法,我们自定义的一个秘钥字符串(secret)。例如:


HMACSHA256(

  base64UrlEncode(header) + "." +

  base64UrlEncode(payload),

  secret)


所以签名可以安全地验证一个JWT的合法性(有没有被篡改过)。


最后,给一个实际生成后的JWT字符串的完整样例:


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJDbGllbnQiLCJqdGkiOiIwZTRjYzVkNC0yMmIzLTQwYzUtOTBjMy0wOTk0MjFjNWRjMjkiLCJpYXQiOiIyMDE4LzcvMyAyOjE3OjQ5IiwiZXhwIjoxNTMwNjI3NDY5LCJpc3MiOiJSYXlQSSJ9.98pAaDVhNwVfiSHQVeXKhYE2ML6WK_f9rYC-iwyQEpU


我们可以拿着这个JWT字符串到https://jwt.io/#debugger试着解析出前两部分的内容。


1.2.3 令牌:


本文的“令牌”指的是用于http传输headers中用于验证授权的JSON数据,它是key和value两部分组成,在本文中,key为“Authorization”,value为“Bearer {JWT字符串}”,其中value除了JWT字符串外,还在前面添加了“Bearer ”字符串,这里可以把它理解为大家约约定俗成的规定即可,没有实际的作用。例如:


{ "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJDbGllbnQiLCJqdGkiOiIwZTRjYzVkNC0yMmIzLTQwYzUtOTBjMy0wOTk0MjFjNWRjMjkiLCJpYXQiOiIyMDE4LzcvMyAyOjE3OjQ5IiwiZXhwIjoxNTMwNjI3NDY5LCJpc3MiOiJSYXlQSSJ9.98pAaDVhNwVfiSHQVeXKhYE2ML6WK_f9rYC-iwyQEpU" }


整体的思路明白了,下面实战起来就不会乱了。


二、 道


搭建完的项目架构应该是这样的:



这里有三块工作区域:


一个是RayPI.Token层,该层主要负责“令牌”的生成和存储。


还有一个是在主项目下面的AuthHelper的TokenAuth,该类为一个中间件,它被注册到客服端和接口之间,在客户端发起http请求时,这个http请求会先被传输到TokenAuth类中,然后该类经过一系列验证和操作(包括了JWT验证),决定是否对给http请求进行授权,然后将请求传递给下一个中间件。


最后一个是系统的系统类Startup.cs,我们将在这里面注册中间件,添加Authorization服务等操作。


2.1 搭建RayPI.Token 层


2.1.1 Model


在RayPI.Token层中新建一个Model文件夹,在该文件夹下新建一个TokenModel类,类的定义如下:


namespace RayPI.Token.Model

{

    /// <summary>

    /// 令牌类

    /// </summary>

    public class TokenModel

    {

        public TokenModel()

        {

            this.Uid = 0;

        }

        /// <summary>

        /// 用户Id

        /// </summary>

        public long Uid { get; set; }

        /// <summary>

        /// 用户名

        /// </summary>

        public string Uname { get; set; }

        /// <summary>

        /// 手机

        /// </summary>

        public string Phone { get; set; }

        /// <summary>

        /// 头像

        /// </summary>

        public string Icon { get; set; }

        /// <summary>

        /// 昵称

        /// </summary>

        public string UNickname { get; set; }

        /// <summary>

        /// 身份

        /// </summary>

        public string Sub { get; set; }

    }

}


该类用于存储客户端的一些基本信息,后面我们需要将它存入到系统缓存中。


2.1.2 缓存类


新建一个RayPIMemoryCache类,该类是一个系统扩展类,用于集成我们常用的对MemoryCache的操作,代码如下:


using System;

using Microsoft.Extensions.Caching.Memory;


namespace RayPI.Token

{

    public class RayPIMemoryCache

    {

        public static MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());


        /// <summary>

        /// 验证缓存项是否存在

        /// </summary>

        /// <param name="key">缓存Key</param>

        /// <returns></returns>

        public static bool Exists(string key)

        {

            if (key == null)

            {

                throw new ArgumentNullException(nameof(key));

            }

            object cached;

            return _cache.TryGetValue(key, out cached);

        }


        /// <summary>

        /// 获取缓存

        /// </summary>

        /// <param name="key">缓存Key</param>

        /// <returns></returns>

        public static object Get(string key)

        {

            if (key == null)

            {

                throw new ArgumentNullException(nameof(key));

            }

            return _cache.Get(key);

        }


        /// <summary>

        /// 添加缓存

        /// </summary>

        /// <param name="key">缓存Key</param>

        /// <param name="value">缓存Value</param>

        /// <param name="expiresSliding">滑动过期时长(如果在过期时间内有操作,则以当前时间点延长过期时间)</param>

        /// <param name="expiressAbsoulte">绝对过期时长</param>

        /// <returns></returns>

        public static bool AddMemoryCache(string key, object value, TimeSpan expiresSliding, TimeSpan expiressAbsoulte)

        {

            if (key == null)

            {

                throw new ArgumentNullException(nameof(key));

            }

            if (value == null)

            {

                throw new ArgumentNullException(nameof(value));

            }

            _cache.Set(key, value,

                    new MemoryCacheEntryOptions()

                    .SetSlidingExpiration(expiresSliding)

                    .SetAbsoluteExpiration(expiressAbsoulte)

                    );


            return Exists(key);

        }

    }

}


2.1.3 RayPIToken类


该类只有一个方法叫IssueJWT,我们将tokenModel传递给这个函数,它会根据tokenModel生成JWT字符串,然后将JWT字符串作为key、tokenModel作为value存入系统缓存中中。


using Microsoft.IdentityModel.Tokens;

using RayPI.Token.Model;

using System;

using System.IdentityModel.Tokens.Jwt;

using System.Security.Claims;

using System.Text;


namespace RayPI.Token

{

    /// <summary>

    /// 令牌类

    /// </summary>

    public class RayPIToken

    {


        public RayPIToken()

        {

        }


        /// <summary>

        /// 获取JWT字符串并存入缓存

        /// </summary>

        /// <param name="tm"></param>

        /// <param name="expireSliding"></param>

        /// <param name="expireAbsoulte"></param>

        /// <returns></returns>

        public static string IssueJWT(TokenModel tokenModel, TimeSpan expiresSliding, TimeSpan expiresAbsoulte)

        {

            DateTime UTC = DateTime.UtcNow;

            Claim[] claims = new Claim[]

            {

                new Claim(JwtRegisteredClaimNames.Sub,tokenModel.Sub),//Subject,

                new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),//JWT ID,JWT的唯一标识

                new Claim(JwtRegisteredClaimNames.Iat, UTC.ToString(), ClaimValueTypes.Integer64),//Issued At,JWT颁发的时间,采用标准unix时间,用于验证过期

            };


            JwtSecurityToken jwt = new JwtSecurityToken(

            issuer: "RayPI",//jwt签发者,非必须

            audience: tokenModel.Uname,//jwt的接收该方,非必须

            claims: claims,//声明集合

            expires: UTC.AddHours(12),//指定token的生命周期,unix时间戳格式,非必须

            signingCredentials: new Microsoft.IdentityModel.Tokens

                .SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes("RayPI's Secret Key")), SecurityAlgorithms.HmacSha256));//使用私钥进行签名加密


            var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);//生成最后的JWT字符串


            RayPIMemoryCache.AddMemoryCache(encodedJwt, tokenModel, expiresSliding, expiresAbsoulte);//将JWT字符串和tokenModel作为key和value存入缓存

            return encodedJwt;

        }

    }

}


2.2. 搭建AuthHelp中间件


在主项目中添加文件夹AuthHelp,在文件夹下添加TokenAuth类。


该类后面我们会把它注册为中间件,用于验证并授权客户端发来的http请求。代码如下:


using Microsoft.AspNetCore.Http;

using RayPI.Token;

using RayPI.Token.Model;

using System;

using System.Collections.Generic;

using System.Security.Claims;

using System.Threading.Tasks;


namespace RayPI.AuthHelper

{

    /// <summary>

    /// Token验证授权中间件

    /// </summary>

    public class TokenAuth

    {

        /// <summary>

        /// http委托

        /// </summary>

        private readonly RequestDelegate _next;

        /// <summary>

        /// 构造函数

        /// </summary>

        /// <param name="next"></param>

        public TokenAuth(RequestDelegate next)

        {

            _next = next;

        }

        /// <summary>

        /// 验证授权

        /// </summary>

        /// <param name="httpContext"></param>

        /// <returns></returns>

        public Task Invoke(HttpContext httpContext)

        {

            var headers = httpContext.Request.Headers;

            //检测是否包含'Authorization'请求头,如果不包含返回context进行下一个中间件,用于访问不需要认证的API

            if (!headers.ContainsKey("Authorization"))

            {

                return _next(httpContext);

            }

            var tokenStr = headers["Authorization"];

            try

            {

                string jwtStr = tokenStr.ToString().Substring("Bearer ".Length).Trim();

                //验证缓存中是否存在该jwt字符串

                if (!RayPIMemoryCache.Exists(jwtStr))

                {

                    return httpContext.Response.WriteAsync("非法请求");

                }

                TokenModel tm = ((TokenModel)RayPIMemoryCache.Get(jwtStr));

                //提取tokenModel中的Sub属性进行authorize认证

                List<Claim> lc = new List<Claim>();

                Claim c = new Claim(tm.Sub+"Type", tm.Sub);

                lc.Add(c);

                ClaimsIdentity identity = new ClaimsIdentity(lc);

                ClaimsPrincipal principal = new ClaimsPrincipal(identity);

                httpContext.User = principal;

                return _next(httpContext);

            }

            catch (Exception)

            {

                return httpContext.Response.WriteAsync("token验证异常");

            }

        }

    }

}


2.3 设置Startup.cs类


在ConfigureServices,我们需要注册两个服务项


1)缓存


services.AddSingleton<IMemoryCache>(factory =>

{

    var cache = new MemoryCache(new MemoryCacheOptions());

    return cache;

});


2)认证


services.AddAuthorization(options =>

{

    options.AddPolicy("System", policy => policy.RequireClaim("SystemType").Build());

    options.AddPolicy("Client", policy => policy.RequireClaim("ClientType").Build());

    options.AddPolicy("Admin", policy => policy.RequireClaim("AdminType").Build());

});


这里放了三个身份,System、Client和Admin,后面如果需要可以再添加。


在Configure中,需要将之前的TokenAuth类注册为中间件


app.UseMiddleware<TokenAuth>();


完整的Startup.cs代码是这样的:


using System;

using System.Collections.Generic;

using System.IO;

using System.Linq;

using System.Threading.Tasks;

using RayPI.SwaggerHelp;

using Microsoft.AspNetCore.Builder;

using Microsoft.AspNetCore.Hosting;

using Microsoft.AspNetCore.Http;

using Microsoft.Extensions.Caching.Memory;

using Microsoft.Extensions.Configuration;

using Microsoft.Extensions.DependencyInjection;

using Microsoft.Extensions.DependencyInjection.Extensions;

using Microsoft.Extensions.Logging;

using Microsoft.Extensions.Options;

using Microsoft.Extensions.PlatformAbstractions;

using RayPI.AuthHelper;

using RayPI.Token;

using Swashbuckle.AspNetCore.Swagger;

using Microsoft.AspNetCore.StaticFiles;


namespace RayPI

{

    /// <summary>

    /// 

    /// </summary>

    public class Startup

    {

        /// <summary>

        /// 

        /// </summary>

        /// <param name="configuration"></param>

        public Startup(IConfiguration configuration)

        {

            Configuration = configuration;

        }

        /// <summary>

        /// 

        /// </summary>

        public IConfiguration Configuration { get; }


        /// <summary>

        /// This method gets called by the runtime. Use this method to add services to the container.

        /// </summary>

        /// <param name="services"></param>

        public void ConfigureServices(IServiceCollection services)

        {

            services.Configure<Dictionary<string, string>>(Configuration.GetSection("Mime"));

            services.AddMvc().AddJsonOptions(options =>

            {

                options.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";//设置时间格式

            });


            #region Swagger

            services.AddSwaggerGen(c =>

            {

                c.SwaggerDoc("v1", new Info

                {

                    Version = "v1.1.0",

                    Title = "Ray WebAPI",

                    Description = "框架集合",

                    TermsOfService = "None",

                    Contact = new Swashbuckle.AspNetCore.Swagger.Contact { Name = "RayWang", Email = "2271272653@qq.com", Url = "http://www.cnblogs.com/RayWang" }

                });

                //添加注释服务

                var basePath = PlatformServices.Default.Application.ApplicationBasePath;

                var xmlPath = Path.Combine(basePath, "APIHelp.xml");

                c.IncludeXmlComments(xmlPath);

                //添加对控制器的标签(描述)

                c.DocumentFilter<SwaggerDocTag>();


                //添加header验证信息

                //c.OperationFilter<SwaggerHeader>();

                var security = new Dictionary<string, IEnumerable<string>> { { "Bearer", new string[] { } }, };

                c.AddSecurityRequirement(security);//添加一个必须的全局安全信息,和AddSecurityDefinition方法指定的方案名称要一致,这里是Bearer。

                c.AddSecurityDefinition("Bearer", new ApiKeyScheme

                {

                    Description = "JWT授权(数据将在请求头中进行传输) 参数结构: \"Authorization: Bearer {token}\"",

                    Name = "Authorization",//jwt默认的参数名称

                    In = "header",//jwt默认存放Authorization信息的位置(请求头中)

                    Type = "apiKey"

                });

            });

            #endregion


            #region Token

            services.AddSingleton<IMemoryCache>(factory =>

            {

                var cache = new MemoryCache(new MemoryCacheOptions());

                return cache;

            });

            services.AddAuthorization(options =>

            {

                options.AddPolicy("System", policy => policy.RequireClaim("SystemType").Build());

                options.AddPolicy("Client", policy => policy.RequireClaim("ClientType").Build());

                options.AddPolicy("Admin", policy => policy.RequireClaim("AdminType").Build());

            });

            #endregion

        }


        /// <summary>

        /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

        /// </summary>

        /// <param name="app"></param>

        /// <param name="env"></param>

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)

        {

            if (env.IsDevelopment())

            {

                app.UseDeveloperExceptionPage();

            }


            #region Swagger

            app.UseSwagger();

            app.UseSwaggerUI(c =>

            {

                c.SwaggerEndpoint("/swagger/v1/swagger.json", "ApiHelp V1");

            });

            #endregion


            #region TokenAuth

            app.UseMiddleware<TokenAuth>();

            #endregion


            app.UseMvc();

            

            app.UseStaticFiles();//用于访问wwwroot下的文件 

        }

    }

}


Tips:


这里有一个坑,不太了解依赖注入和中间件的人很容易踩到(其实就是我自己了)


在Startup.cs的Configure函数中,里面每个app.UseXXXXX();是有一定顺序。可以理解为,这里添加中间件的顺序就是客户端发起http请求时所经过的顺序。


之前我因为把“app.UseMvc();”写到了“app.UseMiddleware<TokenAuth>();”上面去了,结果导致怎么Debug都找不到问题。。。


三、果


搭建完成之后,下面就是测试了。


选择一个测试控制器,在其头上标注[Authorize]属性



然后在TokenAuth的Invoke函数下添加一个断点,在我们调用接口发起http请求后,应该会先命中这个断点,在处理了授权验证之后才会进入我们的接口中。



F5运行,在swagger ui中调用一个需要授权验证的接口(根据Id获取学生信息)



输入1,先不进行任何授权认证的操作,直接点击Excute尝试调用,系统命中Invoke函数下的断点,放行,返回结果如下:



状态码500,还返回了一大段html代码,我们可以将接口的完整地址输入到浏览器地址栏进行访问,就可以看到这段html代码的页面了:



可以看到接口返回了一个错误页,原因就是因为该接口加了授权验证之后,中间件TokenAuth会在http请求的头部(headers)中寻找“Authorization"字段里的”令牌“,因为我们没有向接口递送”令牌“,所以系统就会拒绝我们访问该接口。


现在,我们先调用获取JWT接口(实际项目中不应该有该接口,分发令牌的功能应该集成到登陆功能中,但是这里为了简单直观,我将分发令牌的功能直接写成了接口,以供测试),输入相应的客户端信息,Excute:


 

接口会生成”令牌“,并将令牌存入系统缓存的同时,返回JWT字符串:



我们要复制这串JWT字符串,然后将其添加到http请求的Headers中去。测试方法有两个:


1)可以新建一个html页面,模拟前端写个ajax调用接口,在ajax添加headers字段,如下:


$.ajax({

          url: "http://localhost:3608/api/Admin/Student/1",

          type: ”get“,

          dataType: "json",

          //data: {},

          async: false,

       //手动高亮

          headers: { "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJBZG1pbiIsImp0aSI6IjhjMDEwMzI2LTE4M2MtNGQ5ZC1iMDFjLWFjM2EzNTIzODYxOCIsImlhdCI6IjIwMTgvNy8yIDE1OjAzOjQ4IiwiZXhwIjoxNTMwNTg3MDI4LCJpc3MiOiJSYXlQSSJ9.1Bb7hwoDD12n8ymcQsu79Xm-GDq14GERhS9b-1l1kmg" },

          success: function (d) {

              alert(JSON.stringify(d));

          },

          error: function (d) {

              alert(JSON.stringify(d))

          }

});


2)如果你的swagger像我一样,集成了添加Authrize头部功能,那么可以点击这个按钮进行添加。




这里除了JWT字符串外,前面还需要手动写入“Bearer ”(有一个空格)字符串。点击Authorize保存"令牌"。


再次调用刚才的”根据id获取学生信息“接口,发现获取成功:



可以看到swagger向http请求的headers中添加了我们刚才保存的”令牌“。


看完本文有收获?请转发分享给更多人

关注「DotNet」,提升.Net技能 

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存